"""Provides the Actor-World data model used by Pyre systems"""

import collections
import re
from pyre.core import types, dice

_articles = {'the', 'a'}

def _includes_article(noun):
    for article in _articles:
        if noun.startswith(article + ' '):
            return True


def _get_name(noun):
    noun = str(noun)
    if noun == 'you':
        return 'you'
    elif _includes_article(noun) or noun[0].isupper():
        return noun
    else:
        return 'the {}'.format(noun)


def _get_reflexive(noun):
    return 'yourself' if str(noun) == 'you' else 'itself'


_irregular_verbs = {'do': 'does',
                    'go': 'goes',
                    'can': 'can',
                    'have': 'has'}

def _get_verb(verb, subject):
    if str(subject) == 'you':
        return verb
    else:
        if verb in _irregular_verbs:
            return _irregular_verbs[verb]
        else:
            verb_phrase = verb.split()
            verb, prepositions = verb_phrase[0], verb_phrase[1:]
            conjugated = '{}es' if verb.endswith('s') else '{}s'
            verb_phrase = [conjugated.format(verb)] + prepositions
            return ' '.join(verb_phrase)


def _get_possessive(noun, item, last_noun):
    if str(noun) == 'you':
        possesive = 'your'
    elif noun is last_noun:
        possesive = 'its'
    else:
        possesive = "{}'s".format(_get_name(noun))
    return '{} {}'.format(possesive, str(item))

class Messenger(object):
    """Base for both Actor and World, allowing for easy message aggregation"""

    def __init__(self):
        self._cache = []

    def append_message(self, message, *fmt):
        """
        Messenger.append_message(str, *object): None
        Appends a message to the cache with this Messenger as the source.
        The message will be formatted using the following substitutions:
          %s - subject
          %o - object
          %v - verb
          <verb> - inlined verb
          %p - owner-noun pair
          %a - adjective/adverb
        """

        fmt = iter(fmt)
        objects = [] # subject should always be objects[0]

        def replace(match):
            specification = match.group()
            if specification == '%s': # subject
                noun = next(fmt)
                objects.append(noun)
                objects[0] = noun
                return _get_name(noun)
            elif specification == '%o': # object
                noun = next(fmt)
                objects.append(noun)
                if noun is objects[0]:
                    return _get_reflexive(noun)
                else:
                    return _get_name(noun)
            elif specification == '%p': # possessive
                noun, item = next(fmt)
                if not objects:
                    objects.append(item)
                    return _get_possessive(noun, item, None)
                else:
                    return _get_possessive(noun, item, objects[-1])
            elif specification == '%v': # verb
                return _get_verb(next(fmt), objects[0])
            elif specification == '%a': # adjective/adverb
                return str(next(fmt))
            else: # assume inlined verb
                return _get_verb(specification[1:-1], objects[0])

        message = re.sub('%s|%o|%p|%v|%a|<.+?>', replace, message)
        message = message.capitalize()
        if message[-1] not in '.?!':
            message += '.'

        self._cache.append((self, message))

    def retrieve_messages(self):
        """
        Messenger.retrieve_messages(): list of str
        Returns and clears the messages in the cache
        """

        messages = [msg for _, msg in self._cache]
        self.clear_messages()
        return messages

    def retrieve_collapsed(self):
        """
        retrieve_collapsed(): list of str, int
        Returns a list of message, count tuples, as well as clearing the cache.
        The count will indicate how many consecutive duplicates of the message
        there were in the cache.
        """

        messages = self.retrieve_messages()
        collapsed = []
        count = 1
        for i, text in enumerate(messages):
            if i == len(messages) - 1 or text != messages[i + 1]:
                collapsed.append((text, count))
                count = 1
            else:
                count += 1
        return collapsed

    def clear_messages(self):
        """
        Messenger.clear_messages(): None
        Clears the messages in the cache
        """

        self._cache = []

    def aggregate_messages(self, messenger):
        """
        Messenger.aggregate_messages(Messenger): None
        Moves messages from one Messenger to this one, preserving sources
        """

        self._cache.extend(messenger._cache)
        messenger.clear_messages()

    def filter_messages(self, sources):
        """
        Messenger.filter_messages(set of Messenger): None
        Retains only those messages whose source is in the given set
        """

        self._cache = [msg for msg in self._cache if msg[0] in sources]


class Actor(Messenger):
    """Represents anything which can perform an action in a Pyre system"""

    def __init__(self, face, name=None):
        Messenger.__init__(self)
        self.face = face
        self.name = name or face
        self.world = None
        self.pos = None
        self.expired = False
        self.holder = None
        self.holds = set()

    def act(self):
        """
        Actor.act(): None
        Peforms the action of the the Actor
        """

    def set_pos(self, pos):
        """
        Actor.set_pos(Coordinate): None
        Updates the position of the Actor on its World
        """

        assert self.world
        assert not self.holder

        self.world._remove_from_grid(self)
        self.pos.set_xy(pos[0], pos[1]) # update in place for held
        self.world._add_to_grid(self)

    def move(self, delta):
        """
        Actor.move(Coordinate): None
        Translates the position of the Actor on its World by the given delta
        """

        self.set_pos(self.pos + delta)

    def expire(self):
        """
        Actor.expire(): None
        Marks the Actor and those it hold as expired and ready for removal
        """

        self.expired = True
        for held in self.holds:
            held.expire()

    def attach(self, holder):
        """
        Actor.attach(Actor): None
        Attaches the Actor to a holder. An attached Actor is dependant on the
        holder for position and World, although it will not appear on the grid
        """

        assert not self.holder
        assert not self.world or self.world is holder.world
        assert holder is not self

        if self.world:
            self.world._remove_from_grid(self)
        elif holder.world:
            holder.world._register_actor(self)

        # holder will update pos in place, so reference his actual pos
        self._set_pos(holder.pos)
        self._set_world(holder.world)

        self.holder = holder
        holder.holds.add(self)

    def detach(self):
        """
        Actor.detach(): None
        Detaches the Actor from its holder, placing it on the World grid if
        applicable.
        """

        assert self.holder

        if self.world:
            self.world._add_to_grid(self)
            self._set_pos(types.Coordinate.copy(self.pos))
        self.holder.holds.remove(self)
        self.holder = None

    def get_held(self, cls):
        """
        Actor.get_held(type): set of Actor
        Returns all held actors of the given class
        """

        return {held for held in self.holds if isinstance(held, cls)}

    def _set_pos(self, pos):
        self.pos = pos
        for held in self.holds:
            held._set_pos(pos)

    def _set_world(self, world):
        self.world = world
        for held in self.holds:
            held._set_world(world)

    def __str__(self):
        return self.name


_DEFAULT = types.ColoredChar('.')

class World(Messenger):
    """The map on which Actors interact in a Pyre system"""

    def __init__(self, cols, rows):
        Messenger.__init__(self)
        self.cols, self.rows = cols, rows
        self._register = set()

        self._tiles = [[_DEFAULT for _ in range(cols)] for _ in range(rows)]
        self._passable = [[True for _ in range(cols)] for _ in range(rows)]
        self._translucient = [[True for _ in range(cols)] for _ in range(rows)]
        self._occupants = [[set() for _ in range(cols)] for _ in range(rows)]

        self.look_order = [Actor]
        self.act_order = [Actor]

        self._clock = types.DeltaClock()

    def tick(self):
        """
        World.tick(): None
        Simulates one time step, calling act according the the act order,
        aggregating all actor messages, and removing expired Actor
        """

        actors = self._clock.advance()
        deltas = collections.defaultdict(set)

        for cls in self.act_order:
            for actor in self._get_actors(actors, cls):
                delta = actor.act()
                deltas[delta or 1].add(actor)

        for actor in self._register:
            self.aggregate_messages(actor)

        for delta, actors in deltas.iteritems():
            actors = {actor for actor in actors if not actor.expired}
            actors = {actor for actor in actors if actor in self._register}
            self._clock.schedule(actors, delta)

        self.remove_expired()

    def add_actor(self, actor, pos=None):
        """
        World.add_actor(Actor, Coordinate): None
        Adds an Actor to the World at the given position
        """

        assert not actor.world
        assert not actor.holder

        if not pos:
            pos = self.get_open_tile()

        actor._set_world(self)
        actor._set_pos(types.Coordinate.copy(pos))
        self._add_to_grid(actor)
        self._register_actor(actor)
        self._clock.schedule({actor}, 0)

    def remove_actor(self, actor):
        """
        World.remove_actor(Actor): None
        Removes an Actor from the World
        """

        assert actor.world is self
        assert not actor.holder

        self._unregister_actor(actor)
        self._remove_from_grid(actor)
        actor._set_pos(None)
        actor._set_world(None)
        self._clock.unschedule({actor})

    def remove_expired(self):
        """
        World.remove_expired(): None
        Removes and detaches all expired Actor
        """

        expired = [actor for actor in self._register if actor.expired]
        for actor in expired:
            if actor.holder:
                actor.holder.holds.remove(actor)
                actor.holder = None
            else:
                self._remove_from_grid(actor)
            self._register.remove(actor)

            # dont recurse, as held are also expired
            actor.world = None
            actor.pos = None
        self._clock.unschedule(expired)

    def get_actor_at(self, pos, cls=Actor):
        """
        World.get_actor_at(Coordinate, type): Actor
        Returns an Actor of the given class on the grid at the given position
        """

        return World._get_actor(self._occupants[pos[1]][pos[0]], cls)

    def get_actors_at(self, pos, cls=Actor):
        """
        World.get_actor_at(Coordinate, type): Actor
        Returns all Actors of the given class on the grid at the given position
        """

        return World._get_actors(self._occupants[pos[1]][pos[0]], cls)

    def get_actor(self, cls):
        """
        World.get_actor_at(type): Actor
        Returns an Actor of the given class on the World
        """

        return World._get_actor(self._register, cls)

    def get_actors(self, cls):
        """
        World.get_actor_at(type): Actor
        Returns all Actors of the given class on the World
        """

        return World._get_actors(self._register, cls)

    @staticmethod
    def _get_actor(actors, cls):
        try:
            return next(actor for actor in actors if isinstance(actor, cls))
        except StopIteration:
            return None

    @staticmethod
    def _get_actors(actors, cls):
        return {actor for actor in actors if isinstance(actor, cls)}

    def look(self, pos):
        """
        World.look(Coordinate): ColoredChar
        Returns the tile that should be drawn for the given position as
        determined by the draw order
        """

        x, y = pos
        occupants = self._occupants[y][x]
        if occupants:
            for cls in self.look_order:
                actor = self._get_actor(occupants, cls)
                if actor:
                    return actor.face
        return self._tiles[y][x]

    def tile_at(self, pos):
        """
        World.tile_at(Coordinate): ColoredChar
        Returns the tile at the given position
        """

        return self._tiles[pos[1]][pos[0]]

    def passable_at(self, pos):
        """
        World.passable_at(Coordinate): bool
        Returns True if the tile at the given position is passable
        """

        return self._passable[pos[1]][pos[0]]

    def translucient_at(self, pos):
        """
        World.translucient_at(Coordinate): bool
        Returns True if the tile at the given position is translucient
        """

        return self._translucient[pos[1]][pos[0]]

    def open_at(self, pos):
        """
        World.passable_at(Coordinate): bool
        Returns True if thethe tile at the given position is passable and has
        no Actor occupants on the grid
        """

        x, y = pos
        return self._passable[y][x] and not self._occupants[y][x]

    def set_tile(self, pos, face, passable, translucient=None):
        """
        World.set_tile(Coordinate, ColoredChar, bool): None
        Updates the attributes of the tile at the given position.
        """

        x, y = pos
        self._tiles[y][x] = face
        self._passable[y][x] = passable
        if translucient is None:
            self._translucient[y][x] = passable
        else:
            self._translucient[y][x] = translucient

    def get_random_tile(self, select, tries=100):
        """
        World.get_random_tile(func, int): Coordinate
        Attempts to return a random tile for which the select function is True
        """

        for _ in range(tries):
            pos = dice.rand_coordinate(self.cols, self.rows)
            if select(pos):
                return pos

        # failed to randomly select, try the whole map now
        return dice.choice(self._find_all(select))

    def _find_all(self, select):
        cands = ((x, y) for x in range(self.cols) for y in range(self.rows))
        return (types.Coordinate.copy(p) for p in cands if select(p))

    def get_passable_tile(self, tries=100):
        """
        World.get_passable_tile(int): Coordinate
        Attempts to return a random passable tile
        """

        return self.get_random_tile(self.passable_at, tries)

    def get_open_tile(self, tries=100):
        """
        World.get_open_tile(int): Coordinate
        Attempts to return a open passable tile
        """

        return self.get_random_tile(self.open_at, tries)

    def closest_open(self, pos):
        """
        World.closest_open(Coordinate): Coordinate
        Returns the closest open tile to the given position
        """

        pos = types.Coordinate.copy(pos)
        if self.open_at(pos):
            return pos
        else:
            cands = list(self._find_all(self.open_at))
            return min(self._find_all(self.open_at), key=pos.steps_to)

    def inbounds(self, pos):
        """
        World.inbounds(Coordinate): bool
        Returns True if the given Coordinate is in the bounds of the World grid
        """

        return 0 <= pos[0] < self.cols and 0 <= pos[1] < self.rows

    def _add_to_grid(self, actor):
        x, y = actor.pos
        self._occupants[y][x].add(actor)

    def _remove_from_grid(self, actor):
        x, y = actor.pos
        self._occupants[y][x].remove(actor)

    def _register_actor(self, actor):
        self._register.add(actor)
        for held in actor.holds:
            self._register_actor(held)

    def _unregister_actor(self, actor):
        self._register.remove(actor)
        for held in actor.holds:
            self._unregister_actor(held)
